Working with Mixtures

In version 2.1, OpenPNM introduced a new Mixture class, which as the name suggests, combines the properties of several phases into a single mixture. The most common example would be diffusion of oxygen in air, which is of course a mixture of $O_2$ and $N_2$ (ignoring humidity and other minor gases like $CO_2$). The basic premise is that you create normal OpenPNM Phase object for each of the pure components, then create a Mixture object where you specify the composition of each species. The mixture object then provides an interface to manage the properties of each species, such as setting the composition or calculating the molar mass of the mixture. The notebook gives an overview of how this Mixture class works.

What problems does the Mixture class solve? It actually solves three problems or points of confusion, which can be illustrated by considering the diffusion of oxygen in air. In traditional OpenPNM scripts, a user creates a GenericPhase object, called air, and specifies a diffusion coefficient (say 2.05e-5 m2/s). This air object is then used in the FickianDiffusion algorithm which finds 'pore.concentration', but only the user actually knows which species this refers to. OpenPNM just solves the problem with given boundary conditions. Assuming air has a total molar concentration of 40,000 mol/m3, by putting a boundary condition of 5000 mol/m3, you are implicitly telling OpenPNM to solve for oxygen. Had you put 35000 mol/m3, you'd have solved for nitrogen concentration. The new mixture class is fully compatible with existing algorithms, but you would need to override the default quantity from 'pore.concentration' to 'pore.concentration.oxygen'. Nothing really changes except it is now fully explicity and transparent what is being solved for. The second benefit is for calculating physical properties of the phase. A traditional GenericPhase object does not know the physical properties of it's components. Consider the Fuller correlation for binary diffusion coefficients. It requires the "molar diffusion volume" for each species, which are tabulated in handbooks. The traditional Fuller diffusion model in the OpenPNM.models library accepts these values as arguments. A new model has been added that looks for these values stored on the each of the component objects. So the new approach allows for more automated and consistent calculation of mixture properties, rather than manually specifying them. Again, this is most a matter of clarity and convenience. Finally, the mixture class allows for the specification of mulitple concentations. When dealing with a binary phase like are it's possible to implicitly assume that 'pore.concentration' refers to oxygen, and that the nitrogen concentration can be found. In a mixture with three or more components knowing a single composition is no longer sufficient, so it becomes a matter of necessity to specify multiple concentrations, which the mixture class allows by appending the component name to the end of the property (i.e. 'pore.concentration.oxygen' and 'pore.concentration.nitrogen' and 'pore.concentration.water_vapor')


In [1]:
import openpnm as op
ws = op.Workspace()
ws.settings['loglevel'] = 40

Start by defining a simple 2D network (for easier visualization):


In [2]:
pn = op.network.Cubic(shape=[4, 4], spacing=0.001)
geo = op.geometry.StickAndBall(network=pn, pores=pn.Ps, throats=pn.Ts)

In principle, you can define the two pure species as GenericPhase objects, but this leads to problems later since you have to add all the needed physical properties (i.e. molecular weight). A better option is to use the the pre-defined classes in the OpenPNM.mixture submodule. Note that this is not imported with OpenPNM by default so you must import it explicitly:


In [3]:
from openpnm.phases import mixtures
O2 = mixtures.species.gases.O2(network=pn, name='oxygen')
N2 = mixtures.species.gases.N2(network=pn, name='nitrogen')

These species objects do not have many pre-defined properties, but this could grow in the future.


In [4]:
print(O2)


――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
openpnm.phases.mixtures.species.gases.O2 : oxygen
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Properties                                    Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.molar_diffusion_volume                      16 / 16   
2     pore.molecular_weight                            16 / 16   
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Labels                                        Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.all                                      16        
2     throat.all                                    24        
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

It's also possible for users to add their own specific properties to each species. For instance, if you have a correlation that requires the critical temperature and/or pressure of the components, you could easily add:


In [5]:
O2['pore.critical_temperature'] = 154.581
O2['pore.critical_pressure'] = 5043000.0
N2['pore.critical_temperature'] = 126.21
N2['pore.critical_pressure'] = 3390000.0
print(O2)


――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
openpnm.phases.mixtures.species.gases.O2 : oxygen
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Properties                                    Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.critical_pressure                           16 / 16   
2     pore.critical_temperature                        16 / 16   
3     pore.molar_diffusion_volume                      16 / 16   
4     pore.molecular_weight                            16 / 16   
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Labels                                        Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.all                                      16        
2     throat.all                                    24        
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

With the two 'pure' phases defined, we can now create the mixture phase.


In [6]:
air = mixtures.GenericMixture(network=pn, components=[N2, O2])

Now we can print the air object and see how the properties of the mixture are represented, as well as a list of the components that make the phas (at the bottom):


In [7]:
print(air)


――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
openpnm.phases.mixtures.GenericMixture : mix_03
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Properties                                    Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.mole_fraction.all                            0 / 16   
2     pore.mole_fraction.nitrogen                       0 / 16   
3     pore.mole_fraction.oxygen                         0 / 16   
4     pore.pressure                                    16 / 16   
5     pore.temperature                                 16 / 16   
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Labels                                        Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.all                                      16        
2     throat.all                                    24        
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
Component Phases
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
openpnm.phases.mixtures.species.gases.N2 : nitrogen
openpnm.phases.mixtures.species.gases.O2 : oxygen
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――

If you have the handle to a mixture, but not to the components, they can be retrieved from the components attribute, which is a dictionary.


In [8]:
air.components.keys()


Out[8]:
dict_keys(['nitrogen', 'oxygen'])

From the printout of air above we can see that the two components are named 'oxygen' and 'nitrogen' so we can do the following:


In [9]:
O2 = air.components['oxygen']
N2 = air.components['nitrogen']

It's possible to add and remove components after instantiation using the set_component method:


In [10]:
air.set_component(component=O2, mode='remove')
print("After deleting O2, there is just N2:")
print(air.components.keys())
air.set_component(component=O2, mode='add')
print("And O2 can be readded:")
print(air.components.keys())


After deleting O2, there is just N2:
dict_keys(['nitrogen'])
And O2 can be readded:
dict_keys(['nitrogen', 'oxygen'])

Of course, the air object needs to know the concentration of each species. The Mixture class has a method for setting this.


In [11]:
air.set_mole_fraction(component=O2, values=0.21)

As can be seen above, the 'pore.mole_fraction' property has the pure component name appended to the end so we can tell them apart. We can also look at the values within each array to confirm they are correct:


In [12]:
print(air['pore.mole_fraction.oxygen'])


[0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21 0.21
 0.21 0.21]

Note that you only need to specify N-1 mole fractions and the N-th one can be determined. As N2 composition is not yet specified it will be all nans.


In [13]:
print(air['pore.mole_fraction.nitrogen'])


[nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan nan]

But the update_mole_fractions method will find the component with nans as set them to the necessary value for the summation of mole fractions to be 1.0 in all pores.


In [14]:
air.update_mole_fractions()
print(air['pore.mole_fraction.nitrogen'])


[0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79 0.79
 0.79 0.79]

The mixture object also has a few pore-scale models pre-added, such as the ability to find the molecular mass of the mixture:


In [15]:
air['pore.molar_mass']


Out[15]:
array([0.02884, 0.02884, 0.02884, 0.02884, 0.02884, 0.02884, 0.02884,
       0.02884, 0.02884, 0.02884, 0.02884, 0.02884, 0.02884, 0.02884,
       0.02884, 0.02884])

The molar mass model uses the mole fraction of each component on the mixture object (illustrated above) and also looks up the molecular weight from each individual species. This is why it's helpful to use the pre-defined species objects in the mixtures submodule since they have some properties of the pure species included.

The mixture object is able to access the information of it's components using the following:


In [16]:
air['pore.molecular_weight.oxygen']


Out[16]:
array([0.032, 0.032, 0.032, 0.032, 0.032, 0.032, 0.032, 0.032, 0.032,
       0.032, 0.032, 0.032, 0.032, 0.032, 0.032, 0.032])

Now let's see the mixture class in action with a FickianDiffusion algorithm. First let's define a physics object:


In [17]:
phys = op.physics.GenericPhysics(network=pn, phase=air, geometry=geo)

Before add a pore-scale model for diffusive conductance, however, let's consider the diffusion coefficient. We know that the diffusion coefficient of O2 in air is 2.05e-5 m2/s, so we could just hard code that in. But a better way is to use the Fuller correlation. This is implemented in OpenPNM in 2 ways. The first way requires passing in the molar diffusion volume and molecular mass of each species as arguments:


In [18]:
N2['pore.molar_diffusion_volume']


Out[18]:
array([17.9, 17.9, 17.9, 17.9, 17.9, 17.9, 17.9, 17.9, 17.9, 17.9, 17.9,
       17.9, 17.9, 17.9, 17.9, 17.9])

In [19]:
mod = op.models.phases.diffusivity.fuller
air.add_model(propname='pore.diffusivity_old',
              model=mod, MA=0.032, MB=0.028, vA=16.6, vB=17.9)
print(air['pore.diffusivity_old'])


[2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05]

This produces a pretty good estimate, but requires looking up the molar diffusion volumes and masses. The species objects already have this information on them, so OpenPNM provides a second version of the Fuller correlation that automatically retrieves it:


In [20]:
mod = op.models.phases.mixtures.fuller_diffusivity
air.add_model(propname='pore.diffusivity',
              model=mod)
print(air['pore.diffusivity'])


[2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05
 2.06754784e-05 2.06754784e-05 2.06754784e-05 2.06754784e-05]

As can be seen, this is much cleaner and produces the same numbers.

Now we can add the diffusive conductance model to the physics object and run the diffusion simulation:


In [21]:
phys.add_model(propname='throat.diffusive_conductance',
               model=op.models.physics.diffusive_conductance.ordinary_diffusion)

In [22]:
fd = op.algorithms.FickianDiffusion(network=pn)
fd.setup(phase=air, quantity='pore.concentration.oxgyen')
fd.set_value_BC(pores=pn.pores('left'), values=1)
fd.set_value_BC(pores=pn.pores('right'), values=0)
fd.run()

Printing the algorithm object reveals that it did indeed solve for 'pore.concentration.oxygen' as desired.


In [23]:
print(fd)


――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
openpnm.algorithms.FickianDiffusion : alg_01
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Properties                                    Valid Values
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.bc_rate                                      0 / 16   
2     pore.bc_value                                     8 / 16   
3     pore.concentration.oxgyen                        16 / 16   
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
#     Labels                                        Assigned Locations
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
1     pore.all                                      16        
2     throat.all                                    24        
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――